goog.provide('example.Binding');

goog.require('goog.array');
goog.require('goog.events');
goog.require('goog.events.Event');
goog.require('goog.events.EventTarget');
goog.require('goog.json');



/**
 * Event Fired when the BaseModel Changes.
 * @constructor
 * @param {!string} type
 *        The event type.
 * @param {!BaseModel} data
 *        The associated model..
 * @param {Array.<goog.events.Event>=} opt_chain
 *        Optional chain of events.
 * @param {Object=} opt_target
 *        The optional event target. See parent class docs.
 * @extends {goog.events.Event}
 */
ModelChangeEvent = function(type, data, opt_chain, opt_target) {
  goog.events.Event.call(this, type, opt_target);
  this.data_ = data;
  if (goog.isDefAndNotNull(opt_chain) && goog.isArray(opt_chain)) {
    this.chain_ = opt_chain;
  } else {
    this.chain_ = [];
  }
};
goog.inherits(ModelChangeEvent, goog.events.Event);


/**
 * @private
 * @type {!BaseModel}
 */
ModelChangeEvent.prototype.data_;


/**
 * @private
 */
ModelChangeEvent.prototype.chain_;


/**
 * @private
 * @type {Array.<Object>}
 */
ModelChangeEvent.prototype.changedProps_;


/**
 * @param {Array.<Object>} props
 *        The data stored by this event.
 */
ModelChangeEvent.prototype.setChangedProps = function(props) {
  this.changedProps_ = props;
};


/**
 * @return {Array.<Object>}
 *         The data stored by this event.
 */
ModelChangeEvent.prototype.getChangedProps = function() {
  return this.changedProps_;
};


/**
 * @return {!BaseModel}
 *         The data stored by this event.
 */
ModelChangeEvent.prototype.getData = function() {
  return this.data_;
};


/**
 * @return {Array.<goog.events.Event>} the chain of events which
 * got us to this point.
 */
ModelChangeEvent.prototype.getChain = function() {
  return this.chain_;
};



/**
 * @param {string} id The objects unique id.
 * @constructor
 * @extends {goog.events.EventTarget}
 */
BaseModel = function(id) {
  goog.events.EventTarget.call(this);
  this.id_ = id;
  /** @private
   * @type {boolean}
   */
  this.willInvalidate_ = false;
  /**
   * @private
   */
  this.invalidateFunc_ = goog.bind(this.innerInvalidate_, this);
  /**
   * @type {Array.<Object>}
   * @private
   */
  this.changedProps_ = [];
};
goog.inherits(BaseModel, goog.events.EventTarget);


/**
 * The BaseModel Event Types.
 * @enum {string}
 */
BaseModel.EventType = {
  CHANGE: goog.events.getUniqueId('change')
};


/**
 * @type {string}
 * @private
 */
BaseModel.prototype.id_;


/**
 * @return {string} the getter for the model object's id.
 */
BaseModel.prototype.getId = function() {
  return this.id_;
};


/**
 * @protected
 * @param {string=} opt_propName the property which changed.
 * @param {*=} opt_prevValue the property which changed.
 */
BaseModel.prototype.invalidate =
    function(opt_propName, opt_prevValue) {

  if (goog.isDefAndNotNull(opt_propName)) {
    var o = {name: opt_propName};
    if (goog.isDefAndNotNull(opt_prevValue)) {
      o.prevValue = opt_prevValue;
    }
    this.changedProps_.push(o);
  }

  if (!this.willInvalidate_) {
    //    set a time out to invalidate

    this.willInvalidate_ = true;

    //here you can toggle whether you want model events to fire async or sync
    this.timeVal = setTimeout(this.invalidateFunc_, 10);
  }
  //here you can toggle whether you want model events to fire async or sync
  //  this.invalidateFunc_();
};


/**
 * @private
 */
BaseModel.prototype.innerInvalidate_ = function() {
  //this is really not needed, and will never be used in the app as a rule,
  //but gives us the ability toi force invalidation immediatly for unit testing
  //purposes
  clearTimeout(this.timeVal);
  this.willInvalidate_ = false;
  //set a time out to invalidate
  var e = new ModelChangeEvent(
      BaseModel.EventType.CHANGE, this);
  if (this.changedProps_.length > 0) {
    e.setChangedProps(this.changedProps_);
  }
  this.dispatchEvent(e);
  this.changedProps_ = [];
};


/**
 * @return {!Object} The serialized version of the model.
 */
BaseModel.prototype.toObject = function() {
  return {Id: this.getId()};
};



/**
 * @constructor
 * @param {!string} type The event type.
 * @param {BaseModel|ArrayCollection=} opt_data
 *        The associated model should be the same as the events target.
 * @param {Array.<goog.events.Event>=} opt_chain
 *        Pay attention to this one!
 *        Let's presume we have a User, which has posts, which has comments.
 *        We don't want to manage adding and removing change listeners all
 *        the way through this object chain, in essence what we would like is
 *        the object chain to manage its own listeners and bubble the correct
 *        events through. however, when comment changes, a post will bubble
 *        the event, so we need to know where this event was originating from,
 *        thus I have this array, which is optional. Which will describe the
 *        route any change event in the object model has taken what it's
 *        originator are and what objects it bubbled through on the way. This
 *        should give the UI a fair go at only invalidating that which
 *        has chnaged in the system. this chain should avoid the need for a
 *        deep compare between current and previous states.
 * @param {Object=} opt_target
 *        The optional event target. See parent class docs.
 * @extends {goog.events.Event}
 */
ArrayCollectionEvent = function(type, opt_data, opt_chain,
    opt_target) {
  goog.events.Event.call(this, type, opt_target);

  if (goog.isDefAndNotNull(opt_data)) {
    this.data_ = opt_data;
  }

  if (goog.isDefAndNotNull(opt_chain) && goog.isArray(opt_chain)) {
    this.chain_ = opt_chain;
  } else {
    this.chain_ = [];
  }
};
goog.inherits(ArrayCollectionEvent, goog.events.Event);


/**
 * @private
 * @type {!BaseModel|ArrayCollection}
 */
ArrayCollectionEvent.prototype.data_;


/**
 * @private
 */
ArrayCollectionEvent.prototype.chain_;


/**
 * @param {BaseModel} model
 *        The old item you wish to persist on this event.
 */
ArrayCollectionEvent.prototype.setOldItem = function(model) {
  if (goog.isDefAndNotNull(model)) {
    this.oldItem = model;
  }
};


/**
 * @return {?BaseModel}
 *        Returns the old item stored by this event. For when you use.
 *        @code {ArrayCollection.setItemAt} which replaces the index there.
 */
ArrayCollectionEvent.prototype.getOldItem = function() {
  return this.oldItem;
};


/**
 * @return {BaseModel|ArrayCollection}
 *        The old data stored by this event.
 */
ArrayCollectionEvent.prototype.getData = function() {
  return this.data_;
};


/**
 * @return {Array.<goog.events.Event>}
 *         Returns the chain of events which got us to this point.
 */
ArrayCollectionEvent.prototype.getChain = function() {
  return this.chain_;
};



/**
 * @constructor
 * @extends {goog.events.EventTarget}
 * @param {Array.<BaseModel>=} opt_source The innitial items
 *        in the array collection.
 */
ArrayCollection = function(opt_source) {
  //goog.events.EventTarget.call(this);
  goog.base(this);

  /**
   * @type {Function}
   * @private
   */
  this.childListenerFunc_ = goog.bind(this.propegateEventInternal_, this);

  /**
   * @private
   * @type {Array.<BaseModel.EventType>}
   * The events this array collection will bubble through the system.
   */
  this.childObservableEvents_ = [
    BaseModel.EventType.CHANGE
  ];

  if (!goog.isDefAndNotNull(opt_source)) {
    /**
     * @type {!Array}
     * @private
     */
    this.source_ = [];
  } else {
    this.source_ = opt_source;
    for (var i = 0; i < opt_source.length; i++) {
      /**@type {!BaseModel}*/
      var o = /**@type {!BaseModel}*/ opt_source[i];
      this.addListeners_(
          this.childObservableEvents_, o, this.childListenerFunc_);

    }
  }
};
goog.inherits(ArrayCollection, goog.events.EventTarget);


/**
 * a simple proxy to avoid having to manage event keys myself yet still listen
 * to an array of events
 * @private
 * @param {Array.<string>} events The events you want to listen too.
 * @param {BaseModel} object The object you are adding the
 * listeners too.
 * @param {Function} handler The handler for the event listener.
 */
ArrayCollection.prototype.addListeners_ =
    function(events, object, handler) {
  for (var i = 0; i < events.length; i++) {
    object.addEventListener(events[i], handler);
  }
};


/**
 * a simpler proxy to avoid having to manage event keys myself yet still listen
 * to an array of events
 * @private
 * @param {Array.<string>} events The events you want to listen too.
 * @param {BaseModel} object The object you are adding the
 * listeners too.
 * @param {Function} handler The handler for the event listener.
 */
ArrayCollection.prototype.removeListeners_ =
    function(events, object, handler) {
  for (var i = 0; i < events.length; i++) {
    object.removeEventListener(events[i], handler);
  }
};


/**
 * @param {!BaseModel} item The item you want to add.
 */
ArrayCollection.prototype.addItem = function(item) {
  this.source_[this.source_.length] = item;
  this.addListeners_(this.childObservableEvents_,
      item, this.childListenerFunc_);
  this.dispatchEvent(new ArrayCollectionEvent(
      ArrayCollection.EventType.ADD, item));

};


/**
 * @param {!BaseModel} item The item you want to add.
 * @param {!number} index The index at which you wish to add the item.
 */
ArrayCollection.prototype.addItemAt = function(item, index) {
  goog.array.insertAt(this.source_, item, index);
  this.addListeners_(this.childObservableEvents_, item,
      this.childListenerFunc_);
  this.dispatchEvent(new ArrayCollectionEvent(
      ArrayCollection.EventType.ADD, item));
};


/**
 * @param {BaseModel} item The item you wish to remove.
 */
ArrayCollection.prototype.removeItem = function(item) {
  var index = goog.array.indexOf(this.source_, item);
  if (index >= 0 && index < this.getLength()) {
    this.removeItemAt(index);
  }
};


/**
 * @param {number} index The index at which you wish to remove the item.
 */
ArrayCollection.prototype.removeItemAt = function(index) {
  if (index >= 0 && index < this.getLength()) {
    var item = /** @type {!BaseModel}*/this.source_[index];
    this.source_.splice(index, 1);
    this.removeListeners_(this.childObservableEvents_, item,
        this.childListenerFunc_);
    this.dispatchEvent(new ArrayCollectionEvent(
        ArrayCollection.EventType.REMOVE, item));
  }
};


/**
 * Removes all of the items from this ArrayCollection
 */
ArrayCollection.prototype.removeAll = function() {
  var len = this.source_.length;
  for (var i = 0; i < len; i++) {
    var item = /** @type {!BaseModel}*/this.source_[i];
    this.removeListeners_(this.childObservableEvents_, item,
        this.childListenerFunc_);
  }
  this.source_ = [];
  this.dispatchEvent(new ArrayCollectionEvent(
      ArrayCollection.EventType.REMOVE_ALL));
};


/**
 * @param {number} index The index at which you wish to get the item.
 * @return {?BaseModel} Returns the object if the index is a
 * sane.
 * value.
 */
ArrayCollection.prototype.getItemAt = function(index) {
  return this.source_[index];
};


/**
 * @return {!number} The length of the collection.
 */
ArrayCollection.prototype.getLength = function() {
  return this.source_.length;
};


/**
 * @param {BaseModel} item The item you wish to get the index.
 * @return {!number} The length of the collection.
 */
ArrayCollection.prototype.getItemIndex = function(item) {
  return goog.array.indexOf(this.source_, item);
};


/**
 * @return {string} Returns the qualified class name.
 */
ArrayCollection.prototype.getClassName = function() {
  return 'ArrayCollection';
};


/**
 * @param {BaseModel} item The item you wish to set.
 * @param {number} index The index at which you wish to set the item.
 * @return {?BaseModel} Returns the item.
 */
ArrayCollection.prototype.setItemAt = function(item, index) {
  var oldItem;
  if (index >= 0 && index < this.source_.length) {
    oldItem = this.getItemAt(index);
    this.removeListeners_(this.childObservableEvents_, oldItem,
        this.childListenerFunc_);
    this.source_[index] = item;
    this.addListeners_(this.childObservableEvents_, item,
        this.childListenerFunc_);
    /** @type {ArrayCollectionEvent} */
    var e = new ArrayCollectionEvent(
        ArrayCollection.EventType.SET, item);
    e.setOldItem(oldItem);
    this.dispatchEvent(e);

  }
  return item;
};


/**
 * @return {Array.<BaseModel>}
 *        a copy of the collection source, note, this is not a deep copy .
 */
ArrayCollection.prototype.getSource = function() {
  return this.source_.slice();
};


/**
 * @param {Function} f the sorting function.
 * @return {Array.<BaseModel>} returns a copy of the sorted
 * underlying source array.
 */
ArrayCollection.prototype.sort = function(f) {
  this.source_.sort(f);
  /**
   * @type {ArrayCollectionEvent}
   */
  var e = new ArrayCollectionEvent(
      ArrayCollection.EventType.SORT, this);
  this.dispatchEvent(e);
  return this.getSource();
};


/**
 * @override
 */
ArrayCollection.prototype.dispose = function() {
  //call super
  goog.base(this, 'dispose');
  for (var i = 0; i < this.source_.length; i++) {
    var o = /**@type {BaseModel}*/ this.source_[i];
    this.removeListeners_(this.childObservableEvents_, o,
        this.childListenerFunc_);
    o.dispose();
  }
};


/**
 * @param {ModelChangeEvent} event The child models change
 * event, this just gets dispatched up the chain again.
 * @private
 */
ArrayCollection.prototype.propegateEventInternal_ =
    function(event) {

  var arr = event.getChain();
  arr.push(event);
  var e = new ArrayCollectionEvent(
      ArrayCollection.EventType.CHANGE, this, arr);
  this.dispatchEvent(e);
};


/**
 * @return {Object} the serialized version of this object.
 */
ArrayCollection.prototype.toObject = function() {
  var arr = [];
  for (var i = 0; i < this.source_.length; i++) {
    /** @type {BaseModel} */
    var item = this.source_[i];
    arr.push(item.toObject());
  }
  return arr;
};


/**
 * Specifies the event types dispatched by ArrayCollection
 * @enum {string}
 */
ArrayCollection.EventType = {
  ADD: goog.events.getUniqueId('add'),
  REMOVE: goog.events.getUniqueId('remove'),
  REMOVE_ALL: goog.events.getUniqueId('remove_all'),
  CHANGE: goog.events.getUniqueId('change'),
  SET: goog.events.getUniqueId('set'),
  SORT: goog.events.getUniqueId('sort')
};



/**
 * @constructor
 * @extends {BaseModel}
 * @param {string} id The Book Title.
 * @param {string} title The Book Title.
 * @param {number} no_pages The Number of Pages.
 */
BookModel = function(id, title, no_pages) {
  goog.base(this, id);
  this.title_ = title;
  this.no_pages_ = no_pages;
};
goog.inherits(
    BookModel,
    BaseModel
);


/**
 * @enum {string}
 */
BookModel.Properties = {
  NO_PAGES: 'no_pages',
  TITLE: 'title'
};


/**
 * @private
 * @type {string}
 */
BookModel.prototype.title_;


/**
 * @param {string} title
 *        The Book Title.
 */
BookModel.prototype.setTitle = function(title) {
  var old = this.title_;
  if (title !== old) {
    this.title_ = title;
    this.invalidate(BookModel.Properties.TITLE, old);
  }
};


/**
 * get the Book Title.
 * @return {string}
 *         Returns the book title.
 */
BookModel.prototype.getTitle = function() {
  return this.title_;
};


/**
 * @private
 * @type {number}
 */
BookModel.prototype.no_pages_;


/**
 * @param {number} no_pages
 *        The Book Title.
 */
BookModel.prototype.setNoPages = function(no_pages) {
  var old = this.no_pages_;
  if (no_pages !== old) {
    this.no_pages_ = no_pages;
    this.invalidate(BookModel.Properties.NO_PAGES, old);
    this.invalidateFunc_();
  }
};


/**
 * Gets the number of pages.
 * @return {number}
 *         The number of pages..
 */
BookModel.prototype.getNoPages = function() {
  return this.no_pages_;
};


/**
 * @return {Object}
 *         Returns the book object.
 */
BookModel.prototype.toObject = function() {
  var obj = goog.base(this, 'toObject');
  obj.no_pages = this.getNoPages();
  obj.title = this.getTitle();
  return obj;
};



/**
 * @constructor
 * @extends {BaseModel}
 * @param {string} id The Username.
 * @param {string} name The Username.
 */
UserModel = function(id, name) {
  goog.base(this, id);
  this.name_ = name;
  this.books_ = new ArrayCollection();

  var events = ArrayCollection.EventType;

  goog.events.listen(
      this.books_,
      [
       events.CHANGE,
       events.REMOVE,
       events.ADD,
       events.REMOVE_ALL,
       events.CHANGE,
       events.SET,
       events.SORT
      ],
      this.dispatchBooksChange_,
      false,
      this
  );
};
goog.inherits(
    UserModel,
    BaseModel
);


/**
 * @enum {string}
 */
UserModel.Properties = {
  BOOKS: 'books',
  NAME: 'name'
};


/**
 * @private
 * @param {goog.events.Event} event The Event object.
 */
UserModel.prototype.dispatchBooksChange_ = function(event) {
  this.invalidate(UserModel.Properties.BOOKS);
  this.invalidateFunc_();
};


/**
 * @return {string} The user name.
 */
UserModel.prototype.getName = function() {
  return this.name_;
};


/**
 * @return {Object}
 *         The user object.
 */
UserModel.prototype.toObject = function() {
  /** @type {Object} */
  var obj = goog.base(this, 'toObject');

  /** @type {string} */
  obj.name = this.name_;

  /** @type {Array.<BookModel>} */
  obj.books = this.books_.toObject();
  return obj;
};


/**
 * Adds a book to this users booklist.
 * @param {BookModel} book
  *       a Bookmodel.
 */
UserModel.prototype.addBook = function(book) {
  this.books_.addItem(book);
};


/**
 * Removes a User Book from the booklist
 * @param {BookModel} book The book object to remove.
 */
UserModel.prototype.removeBook = function(book) {
  this.books_.removeItem(book);
};



var collection = new ArrayCollection();
var events = ArrayCollection.EventType;

goog.events.listen(collection, events.ADD, function(event) {
  var txt = 'User added to Array Collection: ';
  txt += goog.json.serialize(event.getData().toObject());
  console.log(txt);
});

goog.events.listen(collection, events.REMOVE, function(event) {
  var txt = 'User removed from Array Collection: ';
  txt += goog.json.serialize(event.getData().toObject());
  console.log(txt);
});

goog.events.listen(collection, events.REMOVE_ALL, function(event) {
  var txt = 'Removed All Users From Array Collection';
  console.log(txt);
});

goog.events.listen(collection, events.CHANGE, function(event) {
  var txt = 'Users properties changed in Array Colletion. ';
  var users = event.getData().toObject();
  goog.array.forEach(users, function(user) {
    txt += user.name + ', ';
  });
  console.log(txt);
});

goog.events.listen(collection, events.SET, function(event) {
  var txt = 'Setting ';
  console.log(txt);
});

goog.events.listen(collection, events.SORT, function(event) {
  var txt = 'Users have been sorted. ';
  var users = event.getData().toObject();
  goog.array.forEach(users, function(user) {
    txt += user.name + ', ';
  });
  console.log(txt);
});


var users = [], books = [], id = 0;
users.push(new UserModel(String(id++), 'Joe'));
users.push(new UserModel(String(id++), 'Dan'));
users.push(new UserModel(String(id++), 'Peter'));
users.push(new UserModel(String(id++), 'Miki'));
users.push(new UserModel(String(id++), 'Tzang'));

books.push(new BookModel(String(id++), 'Tarzan and Goblins', 100));
books.push(new BookModel(String(id++), 'The Princess & dishes.', 232));
books.push(new BookModel(String(id++), 'Tarzan and Goblins in 3D', 10));


collection.addItem(users[0]);
collection.addItem(users[1]);
collection.addItem(users[2]);
collection.addItem(users[3]);
collection.addItem(users[4]);

users[0].addBook(books[0]);
users[0].addBook(books[1]);
users[1].addBook(books[1]);
users[2].addBook(books[2]);

collection.sort(x = function(a, b) {
  var na, nb;
  na = a.getName();
  nb = b.getName();
  if (na === nb) return 0;
  return (na < nb) ? -1 : 1;
});


users[2].removeBook(books[2]);

collection.removeItem(users[3]);
collection.removeItem(users[0]);

collection.removeAll();
